Skip to content

Implement dynamic import.defer() (TC39 Stage 3)#31244

Open
robobun wants to merge 5 commits into
mainfrom
farm/d83170ad/dynamic-import-defer
Open

Implement dynamic import.defer() (TC39 Stage 3)#31244
robobun wants to merge 5 commits into
mainfrom
farm/d83170ad/dynamic-import-defer

Conversation

@robobun

@robobun robobun commented May 23, 2026

Copy link
Copy Markdown
Collaborator

Implements the dynamic form of the Stage 3 Deferred Module Evaluation proposal, completing the feature started in #30975 (static import defer):

const ns = await import.defer("./mod.js");
// mod.js is fetched and linked, but NOT evaluated.

console.log("before");
console.log(ns.value);   // <-- mod.js evaluates here, synchronously

Bun's pinned WebKit already ships the engine side (upstream WebKit/WebKit#64907): the JSC parser accepts import.defer(...) under useImportDefer (which Bun enables), and the moduleLoaderImportModule host hook carries a trailing bool deferred. Bun previously rejected the syntax in its own transpiler and ignored that flag.

What changed

Parser / ASTsrc/js_parser/parse/parse_import_export.rs, src/ast/e.rs, src/js_parser/{parser,p,visit/visit_expr,repl_transforms}.rs

  • import. now accepts the contextual keyword defer followed by the usual ( specifier [, options] ) arguments. The check compares the raw token, so import.def\u0065r(...) stays a syntax error; other identifiers after import. still error.
  • Unlike import.meta, import.defer() does not mark the file as ESM (it is valid in CommonJS, like import()).
  • New E::Import.phase_defer, threaded through import transposition (TransposeState.phase_defer), and ImportRecordFlags::PHASE_DEFER is now also set on ImportKind::Dynamic records.
  • REPL static-import lowering now maps import defer * as X from 'mod'await import.defer('mod').

Printersrc/js_printer/lib.rs

  • External records and non-literal specifiers print import.defer(...), so JSC's own parser sees the phase during bytecode generation.
  • When the bundler inlines the target module, the call degrades to a regular dynamic import (Promise.resolve().then(...)) — the same documented limitation as static import defer. The bake dev server's hmr.dynamicImport path is likewise unchanged.

Runtimesrc/jsc/bindings/ZigGlobalObject.cpp, src/runtime/bake/BakeGlobalObject.cpp, src/jsc/bindings/NodeVM.{h,cpp}

  • moduleLoaderImportModule forwards the deferred flag into JSC::importModule(...) instead of dropping it, for the main global, Bake global, plugin virtual modules, and the node:vm USE_MAIN_CONTEXT_DEFAULT_LOADER path. JSC then links the graph, eagerly evaluates only the async transitive dependencies, and resolves the promise with the deferred namespace (ensureDeferredNamespaceEvaluation handles later property access).
  • node:vm importModuleDynamically callbacks have no phase parameter (same as Node), so deferred imports resolved through a user callback behave like regular dynamic imports.

Semantics covered by tests (test/js/bun/resolve/import-defer.test.ts, 19 new cases)

  • Evaluation deferred until first non-symbol property access; never evaluated if never accessed
  • ns[Symbol.toStringTag] === "Deferred Module"
  • import.defer() and static import defer of the same module yield the same namespace object
  • Throwing module: the import promise resolves; the error is thrown at property access, every time
  • Resolution failures reject the promise
  • Async transitive dependencies are evaluated during the import; a deferred module that itself uses TLA is evaluated during the import
  • Runtime-computed specifiers, import attributes, .ts files, CommonJS callers, CommonJS targets (host-defined eager evaluation)
  • Bun.Transpiler round-trips import.defer(...) and scanImports reports it as dynamic-import
  • Syntax errors: import.defer without a call, escaped def\u0065r, import.source(...)

Bundler coverage (test/bundler/bundler_edgecase.test.ts): inlined targets degrade gracefully and run; --external targets keep import.defer(...) in the output.

Verification

  • bun bd test test/js/bun/resolve/import-defer.test.ts — 39 pass (20 existing static + 19 new)
  • bun bd test test/bundler/bundler_edgecase.test.ts -t DynamicImportDefer — 2 pass
  • New tests fail on USE_SYSTEM_BUN=1 (1.4.0 rejects the syntax with Expected "meta" but found "defer")
  • No regressions: transpiler.test.js, vm.test.ts, repl-transform.test.ts, concurrent-dynamic-import, dynamic-import-tla-cycle, import-meta, import-query all pass

@robobun

robobun commented May 23, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 5:53 PM PT - Jun 18th, 2026

@robobun, your commit c7d8bce has 3 failures in Build #63407 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 31244

That installs a local version of the PR into your bun-31244 executable, so you can run:

bun-31244 --bun

@coderabbitai

coderabbitai Bot commented May 23, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 0e15f598-1fb4-4944-afa0-75ffdec0f585

📥 Commits

Reviewing files that changed from the base of the PR and between 8372138 and c7d8bce.

📒 Files selected for processing (13)
  • src/ast/e.rs
  • src/ast/expr.rs
  • src/ast/import_record.rs
  • src/js_parser/p.rs
  • src/js_parser/parse/parse_import_export.rs
  • src/js_parser/parser.rs
  • src/js_parser/repl_transforms.rs
  • src/js_parser/visit/visit_expr.rs
  • src/js_printer/lib.rs
  • src/jsc/bindings/NodeVM.cpp
  • src/jsc/bindings/NodeVM.h
  • src/jsc/bindings/ZigGlobalObject.cpp
  • src/runtime/bake/BakeGlobalObject.cpp
💤 Files with no reviewable changes (10)
  • src/js_parser/visit/visit_expr.rs
  • src/jsc/bindings/NodeVM.h
  • src/js_printer/lib.rs
  • src/js_parser/parser.rs
  • src/js_parser/p.rs
  • src/jsc/bindings/NodeVM.cpp
  • src/runtime/bake/BakeGlobalObject.cpp
  • src/js_parser/repl_transforms.rs
  • src/js_parser/parse/parse_import_export.rs
  • src/jsc/bindings/ZigGlobalObject.cpp

Walkthrough

Adds end-to-end support for TC39 Stage 3 dynamic import.defer(...): parser recognizes import.defer, AST gains phase_defer, transposition and printers preserve it, JSC/NodeVM module-loader call chain forwards a deferred flag, and tests exercise bundling and runtime semantics.

Changes

Dynamic Import Deferred Evaluation

Layer / File(s) Summary
AST Data Model for Deferred Imports
src/ast/e.rs, src/ast/expr.rs, src/ast/import_record.rs
Import gains phase_defer: bool; deep-clone copies it; PHASE_DEFER doc comment updated to describe both dynamic import.defer('path') and static import defer * as ns from 'path' forms.
Parser: import.defer Syntax
src/js_parser/parse/parse_import_export.rs
parse_import_expr recognizes import.defer(...) form, introduces phase_defer state, and marks import records with PHASE_DEFER in SCAN_ONLY and normal-path code generation.
State Threading Through Parse Pipeline
src/js_parser/parser.rs, src/js_parser/visit/visit_expr.rs
TransposeState struct gains phase_defer: bool field (default false); e_import visitor forwards the AST node's phase_defer value into transposition state.
Dynamic Import Generation and Record Flagging
src/js_parser/p.rs
transpose_import sets ImportRecordFlags::PHASE_DEFER for string-literal specifiers and includes phase_defer field in generated E::Import nodes for both literal and non-literal import paths.
Code Emission and REPL Transformation
src/js_printer/lib.rs, src/js_parser/repl_transforms.rs
JS printer emits import.defer( instead of import( when phase_defer or PHASE_DEFER flag is set; REPL static-to-dynamic import rewrite preserves phase_defer on generated dynamic E::Import nodes.
JSC Module Loader Runtime Integration
src/jsc/bindings/NodeVM.h, src/jsc/bindings/NodeVM.cpp, src/jsc/bindings/ZigGlobalObject.cpp, src/runtime/bake/BakeGlobalObject.cpp
NodeVM::importModule signature gains bool deferred parameter; forwarding paths in ZigGlobalObject::moduleLoaderImportModule and BakeGlobalObject::bakeModuleLoaderImportModule now propagate deferred instead of discarding it.
Comprehensive Test Suite
test/bundler/bundler_edgecase.test.ts, test/js/bun/resolve/import-defer.test.ts
Bundler edge-case tests assert defer-phase handling for bundled, external, and code-split CommonJS targets; runtime test suite covers deferred-evaluation timing, namespace identity, property-access error semantics, attributes, CJS interop, TypeScript support, tooling preservation, and syntax errors.

Possibly related PRs

  • oven-sh/bun#31169: Updates JSC embedder/module-loading API to accept a deferred bool in module loader calls—overlaps with runtime forwarding changes in this PR.
  • oven-sh/bun#30975: Introduces PHASE_DEFER machinery for static import defer * as ns, which this PR extends to dynamic import.defer(...).
  • oven-sh/bun#32437: Also modifies src/jsc/bindings/ZigGlobalObject.cpp::moduleLoaderImportModule parameter forwarding into JSC::importModule—connected at the same call-site level.

Suggested reviewers

  • Jarred-Sumner
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title 'Implement dynamic import.defer() (TC39 Stage 3)' accurately captures the main feature being implemented and is specific and clear.
Description check ✅ Passed The PR description comprehensively covers what changed, how it was verified, and includes detailed implementation details, test coverage, and semantic guarantees.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@test/js/bun/resolve/import-defer.test.ts`:
- Around line 668-669: Move the assertion that checks stderr content before the
assertion that checks the exit code in the syntax-error tests: replace the
pattern expect(exitCode).not.toBe(0);
expect(stderr.toLowerCase()).toContain("error"); with
expect(stderr.toLowerCase()).toContain("error"); expect(exitCode).not.toBe(0);
for each occurrence (the three places that currently use those two assertions).
Ensure you only swap the order of the two existing assertions (leave the exact
match/transform on stderr and the not-to-be(0) check unchanged).
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: cedd6a09-87a3-43a7-afcc-dc584d6c0f45

📥 Commits

Reviewing files that changed from the base of the PR and between a4de1eb and 3940d44.

📒 Files selected for processing (15)
  • src/ast/e.rs
  • src/ast/expr.rs
  • src/ast/import_record.rs
  • src/js_parser/p.rs
  • src/js_parser/parse/parse_import_export.rs
  • src/js_parser/parser.rs
  • src/js_parser/repl_transforms.rs
  • src/js_parser/visit/visit_expr.rs
  • src/js_printer/lib.rs
  • src/jsc/bindings/NodeVM.cpp
  • src/jsc/bindings/NodeVM.h
  • src/jsc/bindings/ZigGlobalObject.cpp
  • src/runtime/bake/BakeGlobalObject.cpp
  • test/bundler/bundler_edgecase.test.ts
  • test/js/bun/resolve/import-defer.test.ts

Comment thread test/js/bun/resolve/import-defer.test.ts Outdated
Comment thread src/js_parser/parse/parse_import_export.rs
Comment thread src/js_printer/lib.rs Outdated

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for addressing both nits — I don't see any remaining issues, but given this threads a new TC39 phase through the parser, printer, and three C++ moduleLoaderImportModule hooks, it's worth a human pass before merging.

Extended reasoning...

Overview

This PR implements dynamic import.defer() (TC39 Stage 3 deferred module evaluation), completing the static import defer work from #30975. It touches 15 files across four layers: AST (E::Import.phase_defer, ImportRecordFlags::PHASE_DEFER extended to dynamic records), parser (parse_import_expr accepts import.defer(...), threads phase_defer through TransposeState/transpose_import), printer (emits import.defer( for external/non-literal records, degrades to plain import( when WRAP_WITH_TO_ESM would defeat it), and the C++ runtime where ZigGlobalObject, BakeGlobalObject, and NodeVM now forward the previously-dropped bool deferred into JSC::importModule. ~310 lines of new tests cover deferral semantics, TLA, error propagation, CJS interop, attributes, transpiler round-tripping, and bundler degradation.

Security risks

None identified. The change is parsing/threading a boolean phase flag for a standards-track syntax form; no auth, crypto, filesystem, or network surface is touched. The C++ changes replace hardcoded false with a forwarded parameter on an existing JSC hook signature.

Level of scrutiny

High. This is a new language-feature implementation that alters how the JS parser branches on import., adds a field to a core AST node, and changes module-loader host-hook behavior across three global-object variants. Subtle interactions (ESM-marker handling for import.meta vs import.defer, escaped-keyword rejection, bundler inlining vs external paths, code-splitting + __toESM interop) are exactly the kind of thing a maintainer should sanity-check against the spec and JSC's expectations.

Other factors

Both of my earlier inline nits (stale "meta" diagnostic, redundant .defer under WRAP_WITH_TO_ESM) and CodeRabbit's assertion-order nit have been addressed in 117a476, 015d3eb, and 8531a4f respectively. The bug-hunting system found no issues on the current revision. Test coverage is thorough (19 new runtime cases + 3 bundler cases) and the PR description documents verification including no-regression runs. Buildkite #57072 reports failures on an earlier commit; I'd defer to a human to confirm those are unrelated/resolved on the latest push.

Comment thread src/js_parser/p.rs
@robobun

robobun commented May 23, 2026

Copy link
Copy Markdown
Collaborator Author

Status for maintainers — the implementation and review cycle are done; the remaining red CI is unrelated flake.

  • All bot review feedback has been addressed (assertion order, Expected "meta" or "defer" diagnostic, dropping the defer phase when the __toESM interop wrapper applies — each with tests). One remaining nit about the import() label in a rare bundler diagnostic was acknowledged and intentionally left for a follow-up (see the resolved thread for reasoning).

  • CI: the diff-relevant tests (test/js/bun/resolve/import-defer.test.ts, the three DynamicImportDefer* bundler cases) pass on every lane that ran them. The two failing builds each failed on a single unrelated test:

    • Build #57081: test/js/bun/util/v8-heap-snapshot.test.ts killed by SIGKILL (OOM) on ubuntu-25.04 x64.
    • Build #57100 (retrigger of the same tree): test/js/bun/s3/s3-stream-cancel-leak.test.ts SIGABRT under ASAN on debian-13 x64, with the stack entirely in Blob::get_stream / ReadableStream::from_blob_copy_ref — code this PR does not touch — plus the already-flaky-tagged test/cli/hot/hot.test.ts on Windows.

    This PR only changes import.defer(...) parsing/printing and forwards the deferred flag in moduleLoaderImportModule; none of those paths are exercised by the failing tests. I've used my one retrigger, so this needs a maintainer to look past the flaky lanes (or re-run them) when reviewing.

@sosukesuzuki sosukesuzuki force-pushed the farm/d83170ad/dynamic-import-defer branch from 792fcd8 to 4519f76 Compare June 1, 2026 05:07
@robobun

robobun commented Jun 1, 2026

Copy link
Copy Markdown
Collaborator Author

Status for maintainers — rebased onto current main (454e3b2884) at head c7d8bce4c9; diff is green, the remaining CI red is darwin-14 lane flake shared with other PRs.

Build #63407: 284 jobs passed, 1 hard-failed lane (darwin-14 aarch64). Failures are terminal.test.ts (90s pty timeout), test-tls-client-destroy-soon.js (byte-count mismatch), and fetch-leak.test.ts (RSS threshold), plus flaky-tagged serve.test.ts on Windows. The first two of those appear identically on the unrelated contemporaneous PR build #63396 (farm/26bff18f/serve-listen-errno), so this is current darwin-14 agent flake, not this PR.

  • Conflict resolved in the rebase: moduleLoaderImportModule: thread referrer asyncEvaluationOrder for TLA self-deadlock skip #32437 added a trailing referrerAsyncOrder argument to JSC::importModule(...) on the same two call sites this PR changes the deferred argument on; merged as (..., nullptr, deferred, referrerAsyncOrder).
  • test/js/bun/resolve/import-defer.test.ts39 pass / 0 fail and the three DynamicImportDefer* bundler cases pass on the rebuilt debug binary; none of the failing CI tests exercise the import.defer() code paths.
  • All bot review feedback has been addressed (assertion order, Expected "meta" or "defer" diagnostic, dropping the defer phase when the __toESM interop wrapper applies — each with tests). All review threads are resolved.

My one allowed retrigger is used, so this needs a maintainer to re-run the flaky darwin lane or merge past it.

@robobun robobun force-pushed the farm/d83170ad/dynamic-import-defer branch from 4519f76 to 8372138 Compare June 7, 2026 13:39
robobun and others added 5 commits June 18, 2026 23:52
Parse `import.defer(specifier[, options])` into `E::Import { phase_defer }`,
mark the dynamic import record with `PHASE_DEFER`, and print the call back
out as `import.defer(...)` so JSC sees the phase. The module loader host
hook now forwards the `deferred` flag it receives from JSC into
`JSC::importModule(...)` (Zig, Bake, and node:vm default-loader paths),
letting JSC link the graph, evaluate only async transitive dependencies, and
resolve with the deferred namespace.

When the bundler inlines the target module, the call degrades to a regular
dynamic import — the same documented limitation as static `import defer`.
With code splitting, a deferred dynamic import of a cross-chunk CommonJS
target gets wrapped in .then((m)=>__toESM(m.default)), which touches the
namespace immediately and would defeat the defer phase on the same line.
Emit a regular dynamic import there instead.
@robobun robobun force-pushed the farm/d83170ad/dynamic-import-defer branch from 8372138 to c7d8bce Compare June 18, 2026 23:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant